浏览器中的 Event Loop

Event loop 是针对运行时而非引擎

首先,我们要弄清楚一点,event loop 是针对 JavaScript runtime environment 的,而非 JavaScript engine。对于 NodeJS 这一 runtime environment 来说,它的 JavaScript engine 是 V8,对于 Chrome 这一 runtime environment 来说,它的 JavaScript engine 是 V8 ;对 Firefox 这一 runtime environment 来说,它的 JavaScript engine 是 SpiderMonkey。event loop 是浏览器和 NodeJS 这两个 JavaScript runtime environment 处理异步的机制,它不属于 JavaScript 引擎的运行机制。另外,浏览器和 NodeJS 的 event loop 是不一样的,这篇文章将集中讨论浏览器这一运行时环境。另外要说明的是,浏览器除了包含 JavaScript runtime environment 之外,还负责 HTML 和 CSS 的解析以及最后渲染图形在屏幕上等工作。

JavaScript Engine

负责解析和执行 JavaScript 代码。最开始的时候,JavaScript 的 engine 只是一个解释器,后来以 V8 为代表的现代引擎实现了一种叫做 JIT(just-in-time) 的及时编译技术,拥有更好的性能。

引擎由以下两个主要部分组成:

  • Memory Heap 堆内存,负责内存分配。
  • Call Stack 调用栈,主要用于记录函数调用的位置。

下图是 Chrome 内的 V8 引擎示意图:

JavaScript runtime Environment

JavaScript runtime environment 的示意图如下(以 Chrome 为例):

  • JavaScript Engine - JavaScript 引擎。
  • Web APIs - 由浏览器提供的 APIs,主要包括 DOM、BOM、AJAX、定时器。
  • Callback Queue - 回调队列。也叫作 task queue 或者 message queue。

event loop 是 JavaScript runtime 实现异步的一种机制,负责协调调度以上三者。它的工作是不停地查看调用栈和回调队列,一旦调用栈为空,就通知回调队列将下一个回调函数发送到调用栈,等引擎执行完回调函数,它再进行下一轮循环。

为什么需要 event loop?

Event loop 的存在,是为了协调事件、用户交互、脚本、UI 渲染、网络处理等行为,防止主线程阻塞。注意下面这段话:

The event loop, the web APIs and the message queue/task queue are not part of the JavaScript engine, it’s a part of browser’s JavaScript runtime environment or Nodejs JavaScript runtime environment (in case of Nodejs). In Nodejs, the web APIs are replaced by the C/C++ APIs.
more

ECMAScript 规范主要是写给 JavaScript 这门语言的引擎实现者看的,而引擎的主要工作是解析并执行脚本。所以 event loop 在实现上的标准没有写在 ECMAScript 规范,而是写在 HTML 规范 中,是 Web application APIs 的组成部分,供各大浏览器厂商参考。总而言之,event loop 是 JavaScript runtime environment 对于异步的实现,而非 JavaScript engine。

下面这些都是不属于 JavaScript 语言特性的 Web APIs:

  • setTimeout, setInterval, setImmediate 等定时器,按朴灵的说法,其本质仍然是事件。定时器并不是特例。到达时间点后,会形成一个事件(timeout 事件)。不同的是,一般事件是靠底层系统或者线程池之类的产生事件,但定时器事件是靠事件循环不停检查系统时间来判定是否到达时间点来产生事件。
  • DOM 网页文档对象模型,主要给 JavaScript 的编程接口。
  • BOM 浏览器对象模型,主要是一些浏览器的 API。
  • XMLHttpRequest

上面提到的这些 APIs,与 DOM 相关的挂载在 document 对象上,与 BOM 相关的挂载在 window 对象上,而 document 对象也被挂载在 window 对象上。另外,属于语言特性的全局属性(包括全局值属性、全局函数属性、全局对象属性)也挂载在 window 对象上,请看下面的代码:

1
2
3
4
5
6
7
8
9
// 以下比较都为 true
window.Infinity === Infinity // 全局值属性
window.parseInt === parseInt // 全局函数属性
window.Array === Array // 全局对象属性
window.setTimeout === setTimeout
window.Promise === window.Promise
window.document === document
window.XMLHttpRequest === XMLHttpRequest
document.location === window.location

Browsing Context

即浏览器执行上下文,是一个向用户呈现 Document 对象的环境。通常来说,浏览器的每一个标签页包含一个 browsing context,每一个 <iframe> 标签也包含一个 browsing context。每个 browsing context 具有如下属性:

  • 一个相对应的 WindowProxy 对象。可以理解为一个封装了浏览器全局对象 window 的对象,包含了在浏览器环境执行 JavaScript 代码所需的初始化信息,比如:
    • Infinity, NaN, undefined 等值属性
    • eval(), isFinite(), isNaN(), parseFloat(), encodeURI() 等函数属性
    • Array, Date, Object, Function, Error, Promise 等构造器属性

另外,必然还有 DOM 和 setTimeout 等各种 Web API。

  • 一个 opener browsing context 属性,它的值是 null 或者另一个 browsing context。初始值为 null
  • 一个 disowned 布尔属性,初始值为 false
  • 一个 is closing 布尔属性,初始值为 false
  • 一个 session history 属性,可以认为包含的是该标签页的会话历史信息。

每一个 browsing context 都有一个 event loop 在调度各种任务间的协同工作。

详解 Event Loop

按照 HTML 规范,Event Loop 有如下 3 种类型:

  • window event loop
  • worker event loop
  • worklet event loop

这里我们主要关注第一种 event loop,即一个浏览器标签页包含一个 event loop 或者一组同源的标签页共享一个 event loop 的情况。下面提到的 event loop 也均指 window event loop。

浏览器的 event loop 至少包含两个队列,macrotask 队列和 microtask 队列。按照 HTML 规范,Event loop 的实现应该至少使用一个队列用于处理 macrotasks,至少一个队列处理 microtasks。Event loop 的实际实现通常分配几个队列用于处理不同类型的 macrotasks 和 microtasks。这使得可以对不同的任务类型进行优先级排序。例如优先考虑一些性能敏感的任务如用户输入。另一方面,因为实际上存在很多 JavaScript 宿主环境,所以有的 event loop 使用一个队列处理这两种任务也不应该感到奇怪。

Macrotask

Macrotasks 包含生成 DOM 对象、解析 HTML、执行主线程 JavaScript 代码、更改当前 URL 还有其他的一些事件如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,macrotask 代表一些离散的独立的工作。当执行完一个 task 后,浏览器可以继续其他的工作如页面重渲染垃圾回收

Microtask

Microtasks 则是完成一些更新应用程序状态的较小任务,如处理 promise 的回调和 DOM 的修改,这些任务在浏览器重渲染前执行。Microtask 应该以异步的方式尽快执行,其开销比执行一个新的 macrotask 要小。Microtasks 使得我们可以在 UI 重渲染之前执行某些任务,从而避免了不必要的 UI 渲染,这些渲染可能导致显示的应用程序状态不一致。

对于浏览器这个 JavaScript runtime environment 来说,microtask 主要包括以下两项:

  • promise 的回调函数
  • MutationObserver 的回调函数,DOM 规范指出 step 5 of queuing a mutation record 会将 MutationObserver 的回调函数放置在 microtask 中。

Event Loop Tick

Event loop 的每一轮循环称为 tick。如下图所示(图片来自 secrets of javascript ninja 这本书):

这张图也不错:

如上图所示,在一次 tick 中,event loop 首先检查 macrotask 队列,如果有一个 macrotask 等待执行,那么执行该任务。当该任务执行完毕后(或者 macrotask 队列为空),event loop 继续执行 microtask 队列。如果 microtask 队列有等待执行的任务,那么 event loop 就一直取出任务执行直到 microtask 队列为空。这里我们注意到处理 microtask 和 macrotask 的不同之处:在一次 tick 中,一次最多处理一个 macrotask (其他的仍然驻留在队列中),然而会一次性处理完所有的 microtask 直至 microtask 队列为空。

紧接着,当执行完所有 microtask 即 microtask 队列为空时,event loop 检查是否需要执行 UI 重渲染,如果需要则重渲染 UI。这样就结束了当次循环,继续从头开始检查 macrotask 队列。

上图还包含了一些细节:

  • 两个任务队列都放置在 event loop 外,这表明将任务添加和任务处理行为分离。在 event loop 内负责执行任务(并从队列里删除),而在 event loop 外添加任务。如果不是这样,那么在 event loop 里执行代码时,发生的任何事件都被忽略,这显然不是我们想要的,因此我们将添加任务的行为和 event loop 分开进行。

  • 两种类型的任务同时只能执行一个,因为 JavaScript 基于单线程执行模型。任务一直执行到完成而不能被其他任务中断,这一特性叫做 run-to-completion。只有浏览器才能停止任务的执行;例如如果某个任务消耗了太多的内存和时间的话,浏览器可以中断其执行。

  • 所有的 microtasks 都应该在下次渲染前执行完,因为其目的就是在渲染前更新应用状态。
  • 浏览器通常每秒尝试渲染页面 60 次,以达到每秒 60 帧(60 fps),这个帧速率通常被认为是平滑运动的理想选择。这意味着浏览器尝试每 16ms 渲染一帧。上图中update rendering操作在 event loop 中进行,这是因为在呈现页面时,页面内容不应该被另一个任务修改。这意味着如果我们想要实现平滑的 UI 效果,单个 event loop 中不能占据太多时间。单个任务和由该任务生成的所有 microtasks 应该在 16 毫秒内完成。

当浏览器完成页面渲染后,event loop 的下一次 tick 中可能发生三种情况:

  • event loop 在另一个 16ms 之前执行的 is rendering needed 的判断处。因为更新 UI 是一个复杂的操作,如果没有明确要求渲染页面,浏览器可能在本次迭代中不执行 UI 渲染。
  • event loop 在上次渲染后约 16ms 处达到 Is rendering needed 判断处。在这种情况下,浏览器更新 UI,用户会认为应用比较流畅。

  • 执行下次任务(及其所有相关的 microtask)花费时间大大超过 16ms。这样浏览器将无法按照目标的帧速率重新渲染页面,UI 也将不会更新。如果运行任务代码不占用太多时间(超过几百毫秒),这种延迟甚至可能感知不到,尤其是对于没有太多动画的页面。另一方面,如果我们花费太多时间,或者页面中含有动画,用户可能认为网页缓慢和没有响应。在最坏的情况下,如果任务执行超过几秒钟,用户的浏览器会显示无响应脚本消息。

    处理事件时应注意其发生的频率和处理所需时间。如在处理鼠标移动事件时应该格外小心。移动鼠标会导致大量的事件排队,因此在该鼠标移动处理程序中执行任何复杂的操作都可能导致应用变得很不流畅。

Task Queue (Callback Queue / Message Queue)

根据 HTML 规范,event loop 具有一个或多个 task queue,即任务队列。任务队列是一个有序的任务列表,这些任务是指负责以下工作的算法:

  • 事件
  • 解析 HTML
  • 回调
  • 获取网络资源
  • 对 DOM 操作作出反应

每个任务都被定义为来自特定的任务源。来自同一个任务源的所有任务(例如,由文档的定时器生成的回调、由鼠标在文档上移动而触发的事件、为该文档的解析器排队的任务)必须始终添加到同一任务队列中,但是来自不同任务源的任务可以被放置在不同的任务队列中。

Generic task sources

以下任务源被本规范和其他规范中许多几乎不相关的特性使用:

  • DOM 操作任务源
    此任务源用于对 DOM 操作做出反应的功能,例如在将元素插入文档时以非阻塞方式发生的事件。
  • 用户交互任务源
    此任务源用于对用户交互做出反应的功能,例如键盘或鼠标输入。响应用户输入而发送的事件(例如点击事件)必须使用在用户交互任务源中排队的任务来触发。
  • 网络任务源
    此任务源用于响应网络活动而触发的功能。
  • 历史遍历任务源
    这个任务源是用来对 history.back() 及类似的 API 进行排队。

浏览器的异步处理模型

目前在网络上找到的一些中文资料无法完全说服自己,而 HTML 规范主要给出的是详实严谨的算法,所以试着按照自己的理解梳理下:
通常情况下,每一个浏览器的标签页都有一个独立的 browsing context。这个 browsing context:

  • 有一个 execution context stack 执行上下文栈
  • 至少有一个而且通常只有一个与之关联的 event loop。

而这里 event loop 有一个 microtask queue 和至少一个 task queue。当一个浏览器标签页加载时,通常是做以下几件事情(按照 HTML 规范是 task 任务):

  • 解析 HTML
  • 当遇到 <script src='...'>, <link>, <img> 等标签时发起网络请求。如果有 defer 属性浏览器则会并行发起网络请求,并在 HTML 解析完毕后执行脚本;如果有 async 属性浏览器则会并行发起网络请求,在脚本资源加载完成后执行。
  • 当遇到内嵌的 script 脚本时,执行脚本内的代码,即将脚本内的代码会形成若干执行上下文,被压入执行上下文栈
  • 渲染 UI

这里需要注意的是,script 脚本内执行的诸如 setTimeout 定时器的回调、通过 XMLHttpRequest 发起的异步网络请求、onclick 等事件触发的回调、通过脚本动态操作 DOM 等,这些操作或者说任务,实际上与上面提到的 task 任务是平行的。也就是说,我们写在 script 标签内的 author code,如果里面包含 setTimoutout 等非 JavaScript 语言特性的代码,JavaScript 引擎会调用外部的 Web APIs,将这些任务放置到 task queue 中。根据 HTML 规范,一个浏览器标签页至少有一个 task queue;而遇到 Promise 或者 MutationObserver,则浏览器会将其放置到 miscrotask queue 中。

简而言之,对于浏览器来说,event loop 这种机制,将 JavaScript 引擎的执行上下文栈(也叫 call stack 调用栈)、Web APIs、task queue 这三者进行协调。下面是一个浏览器的 event loop 的示意简图:

Event loop 实例

推荐使用文章末尾参考链接给出的两个将 event loop 在线可视化工具,非常赞。下面的一组图片清晰的展示了如下代码在浏览器的 JavaScript runtime 中是如何运行的(可点击图片以幻灯片形式查看):

1
2
3
4
5
6
7
console.log('Hi')

setTimeout(function() {
console.log('there')
}, 5000)

console.log('Designvelopers')
  1. 初始状态:

  2. 执行 console.log("Hi");,在控制台打出 Hi 后,该函数的调用帧(执行上下文)从调用栈中弹出。另外,这里的 main() 函数模拟一个 .js 文件的全局调用帧:

  3. 执行 setTimeout(...),调用 Web APIs 中针对 setTimeout() 的接口,开始比对系统时间与定时器设置的延迟时间。接着 setTimeout(...) 从调用栈中弹出。

  4. 执行 console.log("Designvelopers");,在控制台打出 Designvelopers 后,该函数的调用帧从调用栈中弹出。与此同时,Web APIs 仍在另一个线程中比对时间,显然这时还没有到达定时器所设置的 5 秒延迟时间。

  5. 脚本的同步代码都已经执行完,此时调用栈为空。当到达定时器设置的时间后,Web APIs 将定时器的回调函数放入回调队列中排队。

  6. 此时调用栈为空,并且回调队列中只有定时器回调函数这一个任务,直接取出定时器回调在调用栈中执行,形成两个调用帧:

  7. 执行完 console.log("there"); 后,该函数的调用帧从调用栈弹出:

  8. 定时器的回调函数只有一行代码,所以紧接着,回调函数的调用帧也从调用栈弹出:

  9. 完毕。

性能优化

从性能优化的角度出发,压倒一切的指导方针是,以下几种情况必须并行执行,否则可能会导致阻塞 event loop,这包括但不限于:

  • 执行繁重的计算;
  • 显示面向用户的提示;
  • 执行可能需要外部系统参与的操作(即退出流程)。

参考链接和外部资料